iT邦幫忙

2024 iThome 鐵人賽

DAY 7
0

本文同步刊載於 「為你自己學 Python - 匯入模組的時候...

匯入模組的時候...

為你自己學 Python

不管是內建的還是第三方套件,我猜大家寫 Python 程式的時候,或多或少都用 import 關鍵字匯入過模組,那各位猜猜看,以下這三種寫法,有什麼差別嗎?

# 寫法 A
import sys
print(sys.version)

# 寫法 B
import sys as s
print(s.version)

# 寫法 C
from sys import version
print(version)

不同的匯入方式

以結果來說,都可以順利印出 version 而得到 Python 的版本,如果使用 dis 模組檢視 Bytecode 的話,會發現寫法 A 跟寫法 B 幾乎是一樣的,差別在於寫法 A 是把模組透過 STORE_NAME 指令存成 sys,而使用 as 關鍵字的寫法 B 則是另存成 s

1           2 LOAD_CONST               0 (0)
            4 LOAD_CONST               1 (None)
            6 IMPORT_NAME              0 (sys)
            8 STORE_NAME               0 (sys)

4          46 LOAD_CONST               0 (0)
           48 LOAD_CONST               1 (None)
           50 IMPORT_NAME              0 (sys)
           52 STORE_NAME               3 (s)

看起來都是透過 IMPORT_NAME 指令匯入模組,其它的差別不大。不過 from ... import ... 的寫法,執行起來就有一點點不同了:

7          90 LOAD_CONST               0 (0)
           92 LOAD_CONST               2 (('version',))
           94 IMPORT_NAME              0 (sys)
           96 IMPORT_FROM              2 (version)
           98 STORE_NAME               2 (version)
          100 POP_TOP

除了 IMPORT_NAME 之外,還多了 IMPORT_FROM 指令,我們來看看這兩個指令到底做些什麼事。

import 指令

先從 IMPORT_NAME 開始看,從 Python/bytecodes.c 找到關於 IMPORT_NAME 是這樣寫的:

// 檔案:Python/bytecodes.c"

// ... 略 ...
inst(IMPORT_NAME, (level, fromlist -- res)) {
    PyObject *name = GETITEM(frame->f_code->co_names, oparg);
    res = import_name(tstate, frame, name, fromlist, level);
    DECREF_INPUTS();
    ERROR_IF(res == NULL, error);
}
// ... 略 ...

前面的 inst 巨集只是用來簡化 switch ... case ... 的寫法,真正在做事的應該是在 Python/ceval.c 裡的 import_name() 函數:

// 檔案:Python/ceval.c

static PyObject *
import_name(PyThreadState *tstate, _PyInterpreterFrame *frame,
            PyObject *name, PyObject *fromlist, PyObject *level)
{
    // ... 略 ...
}

繼續往下追,看一下裡面在做什麼事:

// 檔案:Python/ceval.c

// ... 略 ...
import_func = PyObject_GetItem(frame->f_builtins, &_Py_ID(__import__));
if (import_func == NULL) {
    if (_PyErr_ExceptionMatches(tstate, PyExc_KeyError)) {
        _PyErr_SetString(tstate, PyExc_ImportError, "__import__ not found");
    }
    return NULL;
}
// ... 略 ...

這是要從內建的函數表裡取得 __import__ 來用,如果找不到的話,會出現 "__import__ not found" 的錯誤訊息。咦?__import__ 這種內建的怎麼會找不到?不出意外的話應該是會有啦,但我們也是可以手賤的把它刪掉:

# 一開始是存在的
>>> __import__
<built-in function __import__>

# 用 del 關鍵字把它刪掉
>>> import builtins
>>> del builtins.__import__

# 再試著 import 其它模組
>>> import sys
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ImportError: __import__ not found

果然就出現預期的錯誤訊息了,玩玩就好,沒事別這樣拿石頭砸自己或同事的腳。回來原本的 import_name() 函數,繼續往下看:

// 檔案:Python/ceval.c

// ... 略 ...
if (_PyImport_IsDefaultImportFunc(tstate->interp, import_func)) {
    Py_DECREF(import_func);
    int ilevel = _PyLong_AsInt(level);
    if (ilevel == -1 && _PyErr_Occurred(tstate)) {
        return NULL;
    }
    res = PyImport_ImportModuleLevelObject(
                    name,
                    frame->f_globals,
                    locals == NULL ? Py_None :locals,
                    fromlist,
                    ilevel);
    return res;
}
// ... 略 ...

真正在做事的應該就是這個 PyImport_ImportModuleLevelObject() 函數了,繼續追!

// 檔案:Python/import.c

PyObject *
PyImport_ImportModuleLevelObject(PyObject *name, PyObject *globals,
                                 PyObject *locals, PyObject *fromlist,
                                 int level)
{
  // ... 略 ...
}

在這個函數裡面會看到這段:

// 檔案:Python/import.c

mod = import_get_module(tstate, abs_name);
if (mod == NULL && _PyErr_Occurred(tstate)) {
    goto error;
}

再追進 import_get_module() 函數的話,就會看到 Python 試著從 sys.modules 字典裡取得模組,如果有就取出來使用,如果沒有,就繼續往下執行 import_find_and_load()

// 檔案:Python/import.c

static PyObject *
import_find_and_load(PyThreadState *tstate, PyObject *abs_name)
{
    // ... 略 ...

    mod = PyObject_CallMethodObjArgs(IMPORTLIB(interp), &_Py_ID(_find_and_load),
                                     abs_name, IMPORT_FUNC(interp), NULL);

    // ... 略 ...
}

這裡會呼叫 importlib 模組的 _find_and_load() 這個函數來尋找並載入模組。不過這個 _find_and_load() 函數並不是公開的 API,所以如果在 REPL 裡要呼叫的話,需要改成 importlib._bootstrap._find_and_load() 這樣的寫法,但更建議直接使用 importlib 模組的 import_module() 函數來匯入模組。

既然追到了 importlib 模組,那就來看看 _find_and_load() 函數到底在做什麼事:

# 檔案:Lib/importlib/_bootstrap.py

def _find_and_load(name, import_):
    module = sys.modules.get(name, _NEEDS_LOADING)
    if (module is _NEEDS_LOADING or
        getattr(getattr(module, "__spec__", None), "_initializing", False)):
        with _ModuleLockManager(name):
            module = sys.modules.get(name, _NEEDS_LOADING)
            if module is _NEEDS_LOADING:
                return _find_and_load_unlocked(name, import_)

    # ... 略 ...

    return module

總算看到用 Python 寫的程式碼,親切多了!

在這個函數裡,會先從 sys.modules 字典裡取得模組,如果有的話就直接回傳,如果沒有的話,就會呼叫 _find_and_load_unlocked() 函數來載入模組。繼續追 _find_and_load_unlocked() 函數應該會看到會把匯入的模組存到 sys.modules 字典裡,這樣下次再匯入的時候就可以直接取用了。

from .. import .. 指令

再來看 IMPORT_FROM 指令,從 Python/bytecodes.c 應該可以找到 IMPORT_FROM 的定義:

// 檔案:Python/bytecodes.c

inst(IMPORT_FROM, (from -- from, res)) {
    PyObject *name = GETITEM(frame->f_code->co_names, oparg);
    res = import_from(tstate, from, name);
    ERROR_IF(res == NULL, error);
}

這裡呼叫了 import_from() 函數,繼續看下去:

// 檔案:Python/ceval.c

static PyObject *
import_from(PyThreadState *tstate, PyObject *v, PyObject *name)
{
    PyObject *x;
    PyObject *fullmodname, *pkgname, *pkgpath, *pkgname_or_unknown, *errmsg;

    if (_PyObject_LookupAttr(v, name, &x) != 0) {
        return x;
    }
    // ... 略 ...
}

這會試著從 v 這個模組裡找出 name 這個屬性,如果找到的話就回傳,找不到的話就會繼續往下執行。這裡的 v 其實就是從 IMPORT_FROM 指令裡的 from 參數傳進來的,也就是說這個 from 參數是用來存放已經匯入的模組的。再繼續往下看:

// 檔案:Python/ceval.c

fullmodname = PyUnicode_FromFormat("%U.%U", pkgname, name);
if (fullmodname == NULL) {
    Py_DECREF(pkgname);
    return NULL;
}
x = PyImport_GetModule(fullmodname);

這個 fullmodname 是嘗試組合出完整模組名稱並從,然後再試著透過 PyImport_GetModule() 函數從 sys.modules 這個字典裡看能不能找到。雖然程式教課書都會教我們要用有意義的名字來命名,而在這個函數裡用了變數 x 表示模組,不知道是不是懶得想名字 :)

簡單的整理一下,import 指令底層呼叫 import_name() 函數再透過 importlib 模組進行查找和匯入,而 from.. import.. 指令則是透過 import_from() 函數來查找模組,這個函數會試著從物件的屬性查找,然後才嘗試從 sys.modules 獲取。

瘋狂的副作用!

在追 _find_and_load_unlocked() 函數的時候,有一行 # Crazy side-effects! 的註解引起我的興趣:

# 檔案:Lib/importlib/_bootstrap.py

def _find_and_load_unlocked(name, import_):
    # ... 略 ...
    if parent:
        if parent not in sys.modules:
            _call_with_frames_removed(import_, parent)
        # Crazy side-effects!
        if name in sys.modules:
            return sys.modules[name]
        parent_module = sys.modules[parent]

到底是什麼副作用會讓 CPython 的原始碼裡加上這段註解?瘋狂是又有多瘋狂?

事實上這段程式碼是在處理循環引用的問題,為了造成特定的情境,這裡我刻意用了比較極端的範例。假設我有個專案結構如下:

├── hello
│   ├── __init__.py
│   ├── child.py
│   └── parent.py
└── main.py

檔案內容如下:

# 檔案:hello/__init__.py

from .parent import give_me_child
# 檔案:hello/child.py

class ChildClass:
    pass
// 檔案:hello/parent.py

from .child import ChildClass

def give_me_child():
    return ChildClass()
# 檔案:main.py

import hello.child

當執行 main.py 的時候,會發生什麼事呢?我們來看看:

  1. Python 準備要匯入 hello.child,而在匯入 child 之前,Python 會先試著匯入 hello 套件。
  2. 正當要匯入 hello 套件的時候,Python 執行了 __init__.py 檔案。
  3. __init__.py 試圖從 parent 模組匯入 give_me_child(),因此需要執行 parent.py 檔案。
  4. 而在 parent.py 的第一行是 from .child import ChildClass,此時,Python 發現它需要匯入 child 模組,這剛好就是我們一開始想要匯入的模組!
  5. 好,匯入了 child 模組,然後把它加到 sys.modules 字典裡。
  6. 程式回到 parent.py,完成 give_me_child 的定義。
  7. 最後再回到一開始的 main.py,但此時 hello.child 已經在 sys.modules 中了,所以直接返回已經匯入的模組。

這大概就是為什麼會有那個 "Crazy side-effects!" 的原因了。在試圖匯入 child 模組的過程中,由於在上層模組或套件的匯入過程中,child 模組就已經被匯入了。

幕後功臣 meta_path

在追 import_find_and_load() 函數的時候,裡面有發現個滿特別的東西:

// 檔案:Python/import.c

static PyObject *
import_find_and_load(PyThreadState *tstate, PyObject *abs_name)
{
    // ... 略 ...
    PyObject *sys_path = PySys_GetObject("path");
    PyObject *sys_meta_path = PySys_GetObject("meta_path");
    PyObject *sys_path_hooks = PySys_GetObject("path_hooks");
    if (_PySys_Audit(tstate, "import", "OOOOO",
                     abs_name, Py_None, sys_path ? sys_path : Py_None,
                     sys_meta_path ? sys_meta_path : Py_None,
                     sys_path_hooks ? sys_path_hooks : Py_None) < 0) {
        return NULL;
    }
    // ... 略 ...
}

sys.path 比較比較簡單,就是個串列,裡面放著 Python 在搜尋模組時候的順序。但 sys.meta_path 是什麼?我們進 REPL 把它印出來看看:

>>> import sys
>>> sys.meta_path
[<class '_frozen_importlib.BuiltinImporter'>,
 <class '_frozen_importlib.FrozenImporter'>,
 <class '_frozen_importlib_external.PathFinder'>]

它也是個串列,裡面依序放了 BuiltinImporterFrozenImporter 以及 PathFinder 這三個類別,前面兩個類別可以在 Lib/importlib/_bootstrap.py 找到實作,而最後一個 PathFinder 則是放在 Lib/importlib/_bootstrap_external.py

這些 Importer 或 Finder,就是 Python 負責匯入模組的傢伙,簡單的說,BuiltinImporter 負責匯入內建模組,FrozenImporter 負責處理「凍結模組」,所謂凍結模組是指被編譯成 Python 直譯器的一部份,所以不需要從硬碟裡載入,啟動速度更快,效能更好,安全性也更高。而 PathFinder 可能最複雜的,它負責用透過文件系統的路徑的來進行模組的查找,實際上會遍歷 sys.path 中的每個路徑,嘗試找到匹配的模組。

這幾個類別都有實作類別方法 find_spec()。在追前面 _find_and_load_unlocked() 函數的時候,裡面有一小段是這樣寫的:

# 檔案:Lib/importlib/_bootstrap.py

def _find_and_load_unlocked(name, import_):
    # ... 略 ...

    spec = _find_spec(name, path)
    # ... 略 ...

再追一下 _find_spec() 函數的實作:

# 檔案:Lib/importlib/_bootstrap.py"

def _find_spec(name, path, target=None):
    meta_path = sys.meta_path

    # ... 略 ...

    for finder in meta_path:
        with _ImportLockContext():
            try:
                find_spec = finder.find_spec
            except AttributeError:
                continue
            else:
                spec = find_spec(name, path, target)

    # ... 略 ...

也就是說,在使用這些 Finder 類別的時候,會依照 sys.meta_path 的順序,先使用 BuiltinImporter,如果找不到會再使用 FrozenImporter,最後才是 PathFinder

如果你覺得這三個內建的 Finder 不夠,你也可以自己客製化自己專屬的 Finder,詳細規格可參閱官網文件說明。

小結

追了原始碼才更清楚,原來 Python 的模組匯入機制是一個比我想像中的複雜,在匯入模組的過程中,是由 C 語言跟 Python 共同合作來完成的,結合了 C 語言的效率和 Python 的靈活性。過程包括模組查找、載入和初始化以及處理循環匯入的問題,也因為使用 sys.modules 字典做快取,也因此在 Python 如果匯入重複的模組的時候,並不會額外佔用額外的資源,就直接從 sys.modules 裡拿就好。

不得不說,模組匯入的設計雖然複雜,但滿有趣的...好吧,至少我覺得有趣啦。

本文同步刊載於 「為你自己學 Python - 匯入模組的時候...


上一篇
Day 6 - 我的 Python 會後空翻!
下一篇
Day 8 - 整數的前世今生
系列文
為你自己讀 CPython 原始碼31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言